WIP: k8s lineages - #230
Merged
Merged
Conversation
TrevorBasinger
marked this pull request as ready for review
July 15, 2026 17:31
TrevorBasinger
force-pushed
the
feature/k8s-lineage
branch
from
July 15, 2026 18:12
2992e54 to
ae983d0
Compare
TrevorBasinger
force-pushed
the
feature/k8s-lineage
branch
from
July 23, 2026 18:42
250ea7e to
86ad381
Compare
Phase-0 spike for the Kubernetes training lineage integration: a KIND harness that proves the existing roar runtime works in vanilla training pods before any roar.backends.k8s code exists. - bootstrap_k8s.sh: pinned kind/kubectl download, 1cp+2worker cluster, dist/ wheel hostPath mounts, in-cluster glaas Service->host endpoint wiring, image pre-pull, pod->glaas preflight probe; destroy_k8s.sh - hand-wrapped single-pod Job template modeling the future `roar k8s prepare` output: wheel staging, `roar run --tracer preload` command wrap, Secret-delivered fragment-session credentials, downward-API identity env - roar-unaware synthetic trainer + wrapper that exports the recorded job as an ExecutionFragment (backend=k8s, task_id contract pod_uid:container:index:attempt) and streams it via the shared fragment transport - smoke tests (k8s_e2e marker): traced reads/writes with hashes, identity metadata, and host-side merge into .roar/roar.db through the shared fragment lineage engine Verified live: 3/3 passing repeatedly (~16s warm); default gate, ruff, and mypy green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 1 of the k8s training lineage integration: a `roar.backends.k8s` backend that instruments plain batch/v1 Jobs submitted through `roar run kubectl apply|create -f <manifest>`, gated by k8s.enabled. - submit planning: pre-registers a GLaaS fragment session, rewrites the manifest (0600 prepared file + context sidecar), returns session_id so the shared submit finalizer reconstitutes after completion; degrades to an uninstrumented submit with a warning when GLaaS is unavailable - manifest rewriter: wraps explicit container commands via a /bin/sh bootstrap that pip-installs the roar runtime and execs pod_entry, falling back to the original command on any bootstrap failure; injects the env/identity contract (Secret-backed session creds, downward-API pod identity, cluster-visible GLAAS_URL); appends the Secret document; fails actionably on missing explicit commands, generateName, or multiple Jobs - pod_entry: roar init + roar run (preload) around the original command, then exports the recorded job as an ExecutionFragment with task identity pod_uid:container:completion_index:restart_attempt and streams it via the shared fragment transport (best-effort; training exit code always wins) - host execution: kubectl submit, prepared-manifest cleanup, Job terminal-condition wait, submit job recorded with the ORIGINAL command and plan-generated parent uid so reproduce re-enters the backend and pod fragments link to the submit node - reconstituter + K8S_FRAGMENT_LINEAGE_BACKEND merge k8s_task child jobs through the shared fragment lineage engine - `roar k8s prepare` CLI for inspectable rewrites (no Secret embedded) - shared roar.execution.fragments.export extracted from the OSMO bundle exporter (OSMO delegates, behavior unchanged) - registry: k8s builtin (priority 95), config section, job-env marker Verified: product-path e2e green on the KIND harness (4 tests: rewrite, streaming, reconstitution into .roar/roar.db, secret hygiene) plus the Phase-0 runtime smoke (7 total); 23 new unit tests; default gate 1993 passed; ruff + mypy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 2 of the k8s training lineage integration on top of the Job backend: operator kind adapters, multi-pod distributed capture, and an attach flow for fire-and-forget submits. - manifest rewriter generalized to a WORKLOAD_KINDS adapter registry: batch/v1 Job, jobset.x-k8s.io JobSet (nested replicatedJobs pod templates), kubeflow.org/v1 PyTorchJob (per-role replica specs), and trainer.kubeflow.org TrainJob (wraps the spec.trainer command/env override; requires an explicit trainer command). Task names carry the replica role; terminal wait conditions unioned across kinds in the new workload_wait module - pod identity node-index chain extended: JOB_COMPLETION_INDEX -> PET_NODE_RANK -> pod-level RANK (PyTorchJob v1) - roar k8s attach WORKLOAD: recovers the parent job uid and Secret name from the cluster object's own env contract, resolves credentials (local key -> cluster Secret -> --session-file), optionally waits, records a local attach job under the recovered parent uid, and reconstitutes streamed fragments — the CI flow - harness: bootstrap --with-jobset installs the JobSet controller (v0.12.0); new distributed e2e covers a 2-pod Indexed Job (shared fragment session, distinct completion-index identities, child-process capture, cross-pod artifact edge with topological step ordering), attach from a fresh project via the cluster Secret, and the same worker as a JobSet through the real controller Verified live on KIND: 12/12 e2e (5 new distributed + 4 product path + 3 smoke); 21 new unit tests (65 total k8s-related); default gate 2003 passed; ruff + mypy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes the remaining Phase-2 transport/capture slices. Bundle fallback (GLaaS-less pods): - k8s.bundle_dir config names a mounted shared volume, injected as ROAR_K8S_BUNDLE_DIR; pod_entry probes GLaaS reachability upfront and writes roar-fragments-<pod>.json bundles there when streaming is unavailable (the streamer swallows per-batch POST failures and reports "streamed" regardless — hence the probe; surfacing streamer failure counts is a noted follow-up). Training never fails on lineage-transport outages - roar k8s ingest-bundles DIR merges a host-visible copy of the bundle volume through the shared fragment lineage engine (bundles.py) Object-store I/O hooks (direct S3 is HTTP, invisible to the tracer): - the k8s backend now ships a RuntimeImportAdapter; the existing roar_inject.pth/sitecustomize dispatch (activated by ROAR_WRAP=1, which pod_entry sets for the traced tree) patches botocore.client.BaseClient._make_api_call plus aiobotocore's async variant (covers s3fs/fsspec) to append S3 data-op events to ROAR_K8S_OBJECT_IO_FILE (object_io.py); hooks only record after the real call succeeds, never raise into user code, and no-op when the env is unset - pod_entry folds the events into the exported fragment as s3:// refs with etag hashes, deduped last-wins per (mode, path) Verified live on KIND: 14/14 e2e — new bundle-fallback test (black-hole cluster GLaaS URL, bundle pulled off the node with docker cp, ingested; note /var/tmp hostPath because KIND nodes mount /tmp as tmpfs which docker cp cannot read) and MinIO S3 test (host-staged dataset, pod get->put captured as job inputs/outputs with etag artifact hashes). 10 new unit tests; default gate 2013 passed; ruff + mypy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the ranged-read fidelity gap that was previously the roar-proxy's main advantage over the in-process hooks (the proxy itself is now explicitly a Phase-3 opt-in webhook-injected sidecar for non-Python S3 clients — see the design doc's resolved open question 5). - object_io: GetObject Range headers are parsed (fully-specified bytes=start-end pairs only) and recorded on events; the loader accumulates deduped ranges per (mode, path) across events while etag/size stay last-wins - shared fragment path (additive): ArtifactRef gains an optional byte_ranges field with validating hydration, and the fragment merge engine now carries it into job_inputs/job_outputs.byte_ranges — previously fragments dropped ranges entirely (Ray/OSMO fragments gain the capability for free) Verified live on KIND: MinIO e2e extended with a ranged GetObject, asserting byte_ranges [[0,9]] on the s3:// input row; 14/14 e2e, 3 new unit tests, default gate 2015 passed, ruff + mypy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes Phase 2 of the k8s training lineage integration. Mount-map rewriting (mount_map.py): - per-container mount maps derived at rewrite time from inline CSI volumes the pod spec can name (GCS FUSE, Mountpoint-for-S3 -> gs://bucket / s3://bucket URIs, subPath-aware) plus an explicit [k8s.mount_map] config table for PVC-backed FUSE drivers whose bucket lives in the cluster-side PV; PVC mounts get pvc://claim identity tags and are never rewritten (their cross-pod edges connect through content hashes) - injected as ROAR_K8S_MOUNT_MAP; pod_entry stamps the map into fragment metadata so capture stays raw and the mapping is auditable; collect_k8s_fragments rewrites ref paths at reconstitution with longest-prefix wins (streaming, bundle, and attach paths all covered) Retry chaos e2e: - a backoffLimit-1 Job whose first pod fails mid-run yields two attempt-distinct k8s_task jobs keyed by pod UID — one exit 1 with partial outputs, one exit 0 with final outputs, never conflated — proving the identity contract under real k8s pod recreation Verified live on KIND: 16/16 e2e (chaos + mount-map new; mount-map via config-declared map over a hostPath volume, asserting rewritten s3:// inputs/outputs and no raw mount paths leaking); 8 new unit tests; default gate 2021 passed; ruff + mypy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…kend
Live operator validation:
- bootstrap --with-kubeflow installs training-operator v1 (v1.9.2) and
trainer v2 (v2.2.1) + runtimes; --with-kuberay installs KubeRay v1.4.0
- PyTorchJob e2e proves the per-role adapter against the real
controller (Master+Worker, RANK-chain node indices 0/1); TrainJob e2e
runs the real TrainJob->JobSet pipeline via a slim-image clone of the
shipped torch-distributed ClusterTrainingRuntime
RayJob delegation (rayjob.py, rewrite_style="rayjob"):
- entrypoint wrapped through the Ray driver entrypoint; runtimeEnvYAML
merged with the roar pip requirement, worker setup hook, and the Ray
env contract (ROAR_JOB_ID = the k8s parent uid so fragments link to
the submit node); credentials only as Secret refs in the RayCluster
pod templates; status.jobStatus terminal handling; reconstitution and
attach delegate to the Ray backend's reconstituter (fragments are Ray
TaskFragments merging as ray_task jobs)
- two live-found env-contract fixes: the merged local-proxy redirect
(AWS_ENDPOINT_URL/ROAR_PROXY_PORT) is stripped (no proxy runs in the
pods; user S3 traffic would hit a dead localhost port), and ROAR_WRAP
stays off (the roar_inject.pth fires in Ray pip virtualenvs before
site-packages are importable and the sitecustomize ABI-repair path
blows the 60s worker registration timeout — observed as an infinite
supervisor start/kill/retry loop)
Registry robustness (shared, found by the live smoke): backend plugin
imports that fail during early sitecustomize discovery were cached
forever ("unknown execution backend: ray" in worker setup hooks even
though deps import fine moments later); get_execution_backend now
retries skipped builtin imports on a lookup miss.
Known gap documented in the e2e and docs: per-task I/O fidelity inside
KubeRay workers under setup-hook-only bootstrap (fragments arrive as
ray_task shells without file refs) — a Ray-backend follow-up; the live
smoke asserts the delegation transport scope.
Verified live on KIND: 19/19 e2e (PyTorchJob + TrainJob + RayJob new);
10 new unit tests; default gate 2029 passed; ruff + mypy clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the per-task capture-fidelity gap on KubeRay found by the delegation live smoke, with two root causes isolated by probing a real worker (startup, open patch, executor patch, and task identity were all verified engaged — yet fragments arrived empty): - pathlib (Path.open/read_bytes/write_bytes) resolves io.open by module attribute, not the builtin, so the builtins-only open patch missed it; without the preload tracer (KubeRay pods) that I/O was invisible. roar_worker._startup now patches io.open alongside builtins.open. On preload-equipped native workers the duplicate python-level captures are absorbed by the existing native > python reconstitution dedup - ray 2.46 does not route task execution through the patched FunctionActorManager.get_execution_info, so the per-task flush never engaged; the KubeRay e2e image is pinned to the native harness's ray 2.54 (rayproject/ray:2.54.0-py312-cpu) where the roar_worker internals are supported The RayJob e2e's strict assertion (ray task file write present in ray_task outputs) is restored and green. Native-path regression check: identical compose-harness results with and without this change (5F/1P parity runs; the failures are a pre-existing runtime-env merge conflict on this dev box — job and driver ray.init envs both carrying ROAR_NO_TELEMETRY under a host ray 2.54 CLI — tracked as a separate follow-up). Ray + k8s unit suites and the full default gate are green. Verified live on KIND: 19/19 e2e including the strict RayJob smoke; default gate 2037 passed; ruff + mypy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 3 slice 1: hermetic runtime staging (closes the Phase-0 "wheel is not hermetic" finding — pods no longer need network installs). - deploy/roar-runtime/Dockerfile: per-ABI dependency trees (cp310-cp313, resolved by uv --python-version without needing each interpreter), each with a generated top-level sitecustomize.py mirroring roar_inject.pth (PYTHONPATH staging doesn't process .pth files); the image doubles as the staging init container (default CMD copies /opt/roar-runtime into the shared mount) and the webhook server base - k8s.runtime_source=image + k8s.runtime_image config: the rewriter injects a roar-runtime-staging init container + emptyDir volume (idempotent by name for webhook reinvocation) and the wrapper activates the ABI-matched tree via PYTHONPATH instead of pip; TrainJob keeps the install path (no inline pod template for an init container); RayJob keeps its runtime-env pip mechanism - scripts/build_runtime_image.sh; .dockerignore wheel exception Verified live on KIND: image-mode e2e green in ~11s (vs ~16s+ for pip mode) — staging init container present, no pip in the wrapper, lineage captured; 4 new unit tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 3 slice 2: the platform-install path. A stdlib HTTPS AdmissionReview server (roar/backends/k8s/webhook.py, served by the roar-runtime image) intercepts CREATE of all five workload kinds in namespaces labeled roar.glaas.ai/lineage=enabled and applies the same manifest rewrite as the CLI path — clients need no roar at all. - per admission: mints the fragment session against GLaaS, creates the credentials Secret through the k8s API (never embedded in the object), rewrites via the shared rewriter (image-staged runtime by default), annotates the workload (roar.glaas.ai/parent-uid, session-id, fragment-secret), returns a JSONPatch - never blocks admission: every internal failure returns allowed with a warning, paired with failurePolicy Ignore; dry-run admissions are side-effect-free (sideEffects NoneOnDryRun); idempotent under reinvocationPolicy IfNeeded via the parent-uid annotation - reconstitution is client-driven via the existing roar k8s attach (reads the webhook-created Secret) - harness: bootstrap --with-webhook builds/loads roar-runtime:dev and deploys via deploy_webhook.sh (self-signed CA, RBAC for secret creation, Deployment/Service/MutatingWebhookConfiguration); the image is always rebuilt (layer cache) — a stale image silently breaks the webhook - fix found live: the API server posts /mutate?timeout=10s — path matching must strip the query string Verified live on KIND: 22/22 e2e — zero-touch test does a plain kubectl apply in a labeled namespace (no roar client), asserts injection (annotations, staging init container, wrapped command), job completion, and attach-recovered lineage keyed to the webhook-minted parent uid; unlabeled-namespace control untouched. 7 new unit tests; default gate 2040 passed; ruff + mypy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…executors Closes the ray 2.46 circle-back — by disproving it. A local probe (worker_process_setup_hook wrapping FunctionActorManager methods) showed ray 2.46 DOES route task execution through get_execution_info; the empty task fragments previously blamed on 2.46 were entirely the pathlib/io.open capture gap, confounded because that fix landed together with the 2.54 image pin, which took the credit. - strict KubeRay e2e verified green on BOTH 2.46.0 and 2.54.0 with the final code; the e2e image is now overridable via ROAR_E2E_RAY_IMAGE (default stays the native harness's 2.54 pin) and rayVersion in the RayJob template tracks the image tag - detect-and-warn insurance: _patch_ray_task_execution_for_native_flush now prints a loud stderr warning (with the ray version) when the function manager cannot be imported or exposes neither get_execution_info nor _make_actor_method_executor, instead of silently degrading to task fragments without identity or file refs - developer doc and design doc corrected Verified: strict RayJob e2e 1/1 on 2.46 and 1/1 on 2.54; ray + k8s unit suites green; default gate 2040 passed; ruff + mypy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add k8s.proxy_sidecar / k8s.proxy_upstream (and webhook ROAR_WEBHOOK_PROXY_SIDECAR / ROAR_WEBHOOK_PROXY_UPSTREAM): the rewriter appends a roar-s3-proxy native sidecar (init container with restartPolicy Always) running the roar-proxy binary from the staged runtime tree, so S3 traffic from clients the botocore hooks cannot see (non-Python binaries, raw HTTP) still lands as s3:// lineage refs. - Requires image staging; install mode warns and skips. - roar-proxy re-signs upstream requests, so the sidecar inherits the workload container's AWS_* credential env entries. - The proxy binds loopback only; the startup probe is an exec probe (kubelet tcpSocket probes dial the pod IP and never succeed). - Wrapped containers get AWS_ENDPOINT_URL=http://127.0.0.1:19191 only when the user has not set their own (explicit endpoints win). - pod_entry parses the proxy log (shared emptyDir) and folds refs into the fragment with capture_method="proxy", deduping objects the hooks already captured. - Image-mode fragments no longer report the staged /roar-runtime/** tree as inputs (filtered at reconstitution; capture stays raw). Live-validated on the KIND harness with a hook-invisible raw-urllib S3 client against MinIO (23/23 k8s e2e). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add deploy/charts/roar-lineage-webhook: webhook Deployment/Service, RBAC (Secret-create ClusterRole), and the MutatingWebhookConfiguration for all five workload kinds. TLS comes either from a supplied secret + tls.caBundle (required-value guarded) or from an optional cert-manager self-signed Issuer/Certificate with CA injection. injection.* values map 1:1 onto the ROAR_WEBHOOK_* env contract, including the proxy sidecar knobs. The harness deploy script now generates self-signed TLS material and installs this chart (helm downloaded into .tools/bin like kind/kubectl), so the zero-touch webhook e2e proves the packaged artifact end-to-end; the e2e webhook-presence check selects the configuration by chart labels instead of the old raw-manifest name. Validated: helm lint clean, cert-manager and manual-TLS renders, and 23/23 k8s e2e against the chart-deployed webhook. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Long training jobs routinely outlive the fragment-session TTL, and an expired session 403s on both append and read — lineage was lost with no recovery path. Pair with the glaas-api renewal endpoint (POST /api/v1/fragments/sessions/:id/renew, token-authenticated and allowed even after expiry): - GlaasFragmentStreamer renews on a 403 batch POST and retries the chunk once; a second 403 gives up normally. Renewal is purely reactive — no client-side expiry clock. - Both fragment reconstituters (k8s, ray) renew-and-retry on a 403 fetch, so a late `roar k8s attach` recovers an expired session too. - GlaasClient.renew_fragment_session for host-side callers. - Streamer exposes delivered/failed/pending counters and close() reports flush success (used by the follow-up transport change). Degrades gracefully against a server without the endpoint (renew 404s -> previous behavior). Live-proven on the KIND harness: 60s-TTL session, 75s job, streamer renews mid-run and the finalizer reconstitutes (tests/backends/k8s/e2e/test_k8s_ttl_renewal.py; skips when the local glaas-api lacks the endpoint). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
emit_fragment_dicts returned "streamed" whenever credentials were present, even when every batch POST failed — mid-run streaming failures were silently lost because callers (k8s pod_entry bundle fallback, ray local merge) only fall back on a non-"streamed" result. It now returns "streamed" only when the streamer delivered everything; partial or total failures log an undelivered-count warning and fall through to local_merge / local_fallback with all fragments (downstream merges dedupe by task identity, so overlap with delivered batches is absorbed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…me envs Every `roar run ray job submit` whose driver called ray.init() died with "Failed to merge the Job's runtime env ... because of a conflict": the submit rewrite delivers ROAR_NO_TELEMETRY (plus the rest of the env contract) in the Job-level runtime env, and the patched driver-side ray.init re-added ROAR_NO_TELEMETRY — ray >= 2.4 rejects the Job/driver merge on ANY duplicated env-var key, even with identical values. The failure was roar conflicting with itself, not a ray version issue. The instrumented driver path now reads the job config ray injects (RAY_JOB_CONFIG_JSON_ENV_VAR) and only adds what the Job env lacks (same for py_executable — a job-level value wins with a warning). The non-instrumented path gets a generic guard that drops roar-added env keys the Job env already carries; user-supplied keys are left alone so genuine conflicts still surface through ray's own error. The merged runtime env that reaches workers is unchanged. Live-validated on the compose harness: the previously-failing runtime-env-conflict and native-tracing e2e pass, and the suite goes from dead-on-arrival instrumented submits to 57 passing (remaining failures are pre-existing capture-fidelity gaps newly reachable now that submits run, tracked separately). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The streamer splits an oversized fragment into parts sharing one task identity after an HTTP 413; the keep-last-per-identity dedup then silently discarded every part but the last, losing most of a large task's reads/writes. Retries are already attempt-distinct in the identity contract (pod uid + restart attempt), so same-identity fragments can only be duplicate deliveries or split parts — dedup now unions reads/writes per path across them (last ref wins, last fragment's scalars win). No wire-format change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ctl global flags Both submit planners saved the fragment-session key (and k8s the prepared manifest) under a bare cwd/.roar, while the run finalizer loads the key from the context-resolved project root — submitting from a project subdirectory created a stray nested .roar and lineage silently never reconstituted locally. Planners now resolve the project .roar via the same upward walk the CLI context uses (ROAR_PROJECT_DIR override, bounded by the git root), shared as roar.execution.fragments.sessions.resolve_project_roar_dir. Covered by unit tests for both backends and a KIND e2e that submits from a nested directory. The kubectl matcher also accepted the verb only at argv[1], so common forms like `kubectl --context prod apply -f job.yaml` silently bypassed instrumentation. The verb now matches anywhere; the existing -f-manifest and single-supported-workload guards keep false positives out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The RayJob rewrite set ROAR_JOB_ID to the k8s parent uid but never passed driver_job_uid to merge_worker_bootstrap_env, so workers had no ROAR_DRIVER_JOB_UID and their fragments merged without a parent — ray_task rows carried no DAG edge back to the k8s submit job (the merge prefers the fragment-level parent, which was empty, and the host-side fallback env is unset in this flow). The live RayJob e2e now asserts every ray_task row parents to the recorded submit job, which is exactly the assertion whose absence let this slip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…et cleanup The admission handler registered the GLaaS session and created the credentials Secret before attempting the manifest rewrite, so a rewrite failure (the step most likely to fail) orphaned both. The rewrite — a pure function of the admitted object — now runs first; external resources are only created once it succeeds. Credential Secrets can never carry an ownerReference (the workload has no UID at CREATE admission), so the chart gains a secretCleanup CronJob (roar.backends.k8s.secret_cleanup, runs on the runtime image) deleting roar-fragment-* Secrets older than the maximum session TTL. It uses its own ServiceAccount with list/delete only; the webhook's stays create-only. Running pods are unaffected (env resolves at pod start) — only late attach credential recovery ages out with the session itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pod wrappers guarded python3/pip/staged-tree presence but exec'd pod_entry unconditionally — an import failure past exec (broken install, ABI mismatch such as the x86-64 staged tree on an arm64 node, unsupported interpreter) killed the container with no way back. An import probe now runs as the last step before exec and falls back to the original command uninstrumented, which also makes non-x86-64 platforms degrade gracefully instead of failing training. The remaining unisolated window (a crash inside pod_entry after the probe) is documented instead of overclaimed. Also adds the missing rayjob/rayjobs attach aliases (a WORKLOAD_KINDS-completeness test keeps future kinds honest) and mentions rayjob in the attach help text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The RayJob rewrite destroyed three kinds of user runtime configuration: pip dicts were flattened to package lists (losing pip_check/pip_version), a user worker_process_setup_hook was silently replaced with roar's, and a user-supplied AWS_ENDPOINT_URL (MinIO, LocalStack, private S3) was stripped along with roar's dead local-proxy redirect. - keep the pip dict shape and append the roar requirement to packages; reject pip requirements-file references with an actionable error - carry a displaced user setup hook as ROAR_USER_SETUP_HOOK; roar's worker_bootstrap.startup chains it after capture installs, and user hook failures propagate exactly as they would uninstrumented - drop the local-proxy redirect from the merge source instead of popping the merged result, so user endpoints survive the rewrite Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pod_entry returned roar run's exit code directly, so a tracer/runtime
failure before the workload launched failed the training job — breaking
the documented lineage-never-blocks-training contract (ARM nodes and
unsupported native boundaries were the likely triggers).
roar run already distinguishes pre-launch setup failures from workload
exits (ExecutionReport.setup_error: nonzero exit with no job created).
Expose it across the subprocess boundary: when ROAR_RUN_REPORT_FILE is
set, the run service writes {exit_code, setup_error, tracer_backend}
before finalizers run. pod_entry reruns the original command
uninstrumented only on a positively reported setup failure — a missing
or unreadable report is ambiguous (roar may have died after the
workload ran) and must propagate rather than risk double-running
non-idempotent training.
New KIND chaos e2e forces the failure with k8s.tracer=ebpf (valid mode,
cannot pass preflight in an unprivileged pod) and proves the Job still
completes with the workload's own outcome and no fabricated lineage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bundle fallback derived its filename from the pod name alone, so two wrapped containers in one pod (or an in-place restart) overwrote each other's fragments last-writer-wins. Bundle names now carry pod-container-attempt identity (attempt resolution mirrors the task_id contract) and are written atomically via os.replace so ingest never reads a torn bundle. The submit matcher also accepted commands with several -f/--filename arguments but rewrote only the first: one workload got lineage and completion polling while the rest were applied unrewritten and unwaited. Multiple manifest arguments now decline k8s instrumentation with an actionable warning and leave the kubectl command untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session .key files carry the raw fragment token but were written with write_text, inheriting the umask (0664 observed) — group/other could read the credential. Keys are now created 0600 from the first byte and swapped in with os.replace so a rewrite of an existing over-permissive key also ends owner-only. Also removes the unused GlaasClient.renew_fragment_session, which put the raw token in the URL query string where access logs and proxies capture it. The live renewal path (fragment_streamer) authenticates with the x-roar-fragment-token header and stays. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cleanup CronJob defaulted on, granting its ServiceAccount cluster-wide list/delete on Secrets — list responses include Secret data, so a default install was one compromised cleanup image away from cluster-wide credential read. Its fixed-age policy also conflicts with session renewal: a renewed workload outliving maxAgeSeconds loses the Secret its retry/scale-up pods reference, and attach recovery with it. The CronJob is now opt-in, with both trade-offs documented in values.yaml, the module docstring, and the developer doc. Liveness-aware (controller-grade) cleanup remains deliberately deferred. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The verb-anywhere matcher accepted global-flag VALUES as the verb: 'kubectl --context apply delete -f job.yaml' and 'kubectl delete -f job.yaml --namespace create' both matched the submit backend, which would register a session, rewrite a manifest being deleted, and wait on a workload that no longer exists. The matcher now finds the first non-flag token after skipping known value-taking kubectl global flags (space and = forms); both review commands are regression tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
prepare passed only requirement, GLaaS URL, tracer, Secret name, and parent UID to the rewriter, so image-staged, bundle-enabled, mount-mapped, proxy, or namespace-overridden configurations previewed differently from what the managed submit would actually apply. Both paths now share manifest_rewrite_config_kwargs (single source of truth for config-derived rewrite kwargs) and the proxy-sidecar cannot-inject warning; prepare gains -n/--namespace mirroring kubectl apply -n semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Dockerfile globs dist/, so multiple wheels silently produced an image containing whichever sorted first — typically a stale build. The build script now fails with the wheel list and prints which wheel a successful build used. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Managed capture requires host-side GLaaS at submit time; bundle_dir covers pod-side outages only. Documented explicitly (with the prepare escape hatch) until the deferred local-first design lands, and the match description updated for structural verb parsing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pod_entry kept .roar/roar.db, object-io events, and the run report in the workload's workdir. On a shared or persistent volume (emptyDir shared by co-located wrapped containers, PVC surviving retries) every participant shared one sqlite db and one events file — racing writes and cross-attributing lineage between containers and attempts. roar state now lives in pod-local storage keyed by the identity contract (pod_uid:container:completion_index:restart_attempt); the workload keeps its own cwd. RoarContext.create now honors an existing ROAR_PROJECT_DIR — the contract config loaders and fragment planners already follow — gated on the directory existing so Ray-propagated host paths inside pods never resolve to a phantom project. An unusable state dir falls back to an uninstrumented run rather than failing training. New shared-workdir KIND e2e: two wrapped containers on one emptyDir volume reconstitute as two distinct k8s_tasks, each owning only its own outputs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tracking import hook routes every import — including the retry's own plugin imports — through handle_import, whose backend resolution lands back in get_execution_backend. Re-entering the retry there fans each hooked import inside each plugin module out into another full retry pass: unbounded mutual recursion that pins the CPU and keeps Ray workers from ever registering (observed at worker startup on the K8s/Ray dogfood cluster, py-spy stack on file). Guard the retry with an in-progress flag and skip it while discovery is running; nested lookups fall through to LookupError, which hook callers already treat as 'no backend yet'. Regression test fails pre-fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
S3 streams CompleteMultipartUpload responses as Transfer-Encoding: chunked whitespace keep-alives. The proxy buffers that body (for ETag extraction) but copied the upstream framing headers verbatim, producing a fixed-length response that claims chunked framing — hyper drops the connection and clients see 'empty reply from server' (curl 52) on every multipart completion. Let hyper frame the body itself. Verified A/B against a chunked fake upstream: pre-fix curl exit 52, post-fix 200 with intact body. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The task-executor wrapper was always synchronous, so wrapping an async
actor method (Ray Train v2 TrainController.run) hid its coroutine-ness
from Ray's dispatch: the un-awaited coroutine became the task result and
failed to pickle ('cannot pickle coroutine object'). Wrap coroutine
functions with an async wrapper that awaits under the same bookkeeping
scope.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On a fresh clone with no .roar directory anywhere, the submit planner running in the workflow's working_directory subdir fell back to <subdir>/.roar while the run finalizer (repo-root cwd) loads from <root>/.roar — the fragment session key was stranded and lineage publication failed even after a fully green training run. Behavior change: the documented no-project fallback inside a git repo is now <git-root>/.roar (matches CLI project-context semantics). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TrevorBasinger
force-pushed
the
feature/k8s-lineage
branch
from
July 29, 2026 14:51
86ad381 to
0334231
Compare
Labels are collected from the raw lineage, but the package's jobs are noise-filtered by normalize_jobs_for_registration. A label on a dropped (noise) job was left pointing at a job absent from the package, so publishing failed GLaaS validation with 'Package label references a job not in the package' — observed on the distributed XGBoost dogfood after a fully successful training run (job COMPLETED, 239 jobs / 300 artifacts reconstituted, publication rejected). Filter job-scoped labels to the package's job set before attaching; dag/artifact labels are unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This branch adds Kubernetes-native lineage capture to roar — a
k8sexecutionbackend, a mutating webhook for zero-touch instrumentation, a Helm-packaged lineage
operator, and distributed Ray-on-Kubernetes provenance capture — and hardens the
existing Ray/publish paths until the distributed XGBoost/HIGGS tracer bullet runs
green end-to-end on a private EKS/KubeRay cluster, including lineage publication to
GLaaS. Version bumps to 0.4.2.
The branch has been rebased onto current
main(force-pushed), so the diff againstmainis only this feature's work.What's included
Kubernetes lineage capture (the core feature)
k8sexecution backend — a first-class Kubernetes Job execution backend, withdistributed workload adapters and
roar k8s attach(feat(k8s): add Kubernetes Job execution backend,add distributed workload adapters and roar k8s attach).rewrites workloads to inject roar instrumentation with no changes to user manifests,
packaged as a Helm chart with an aged-Secret cleanup CronJob (shipped disabled by
default). The webhook creates no resources before the rewrite (
feat(k8s): add mutating webhook injector for zero-touch lineage,package the lineage webhook as a Helm chart,webhook creates no resources before the rewrite; aged-Secret cleanup).roar-runtimeimage and an image-staged runtime pathso clusters can run instrumented workloads without a network install, with a bundle-mode
fallback and in-pod object-store I/O capture (
feat(k8s): add roar-runtime image and image-staged runtime mode,add bundle-mode fallback and in-pod object-store I/O capture).RayJob to the Ray backend, parents RayJob Ray tasks to the recorded submit job, and
captures ranged S3 reads as
byte_rangeedges through fragments (feat(k8s): validate operators live and delegate RayJob to the Ray backend,capture ranged S3 reads as byte_ranges,fix(k8s): parent RayJob ray tasks to the recorded submit job).feat(k8s): opt-in roar-proxy S3 sidecar).undelivered batches instead of claiming they streamed, owner-only session keys, and
reconstitution-time merging of 413-split fragment parts (
feat(fragments): renew expired fragment sessions on 403,report undelivered batches…,fix(fragments): owner-only session keys…,fix(k8s): merge 413-split fragment parts at reconstitution).(
test(k8s): add Tier-1 KIND lineage smoke harness,feat(k8s): add mount-map rewriting and retry-chaos coverage).identity + multi-manifest submit guard, uninstrumented-run fallback on preflight failure,
preserve user RayJob runtime env during instrumentation, single-wheel runtime-image
build, and plan-time
.roarresolution at the project root.Distributed dogfood hardening (Ray + publish)
These fixes were surfaced by driving the distributed XGBoost/HIGGS workload end-to-end on
a private EKS/KubeRay cluster. Each carries a regression test.
fix(execution): non-reentrant skipped-backend import retry. The driver could notresolve the
rayexecution backend when discovery ran at interpreter startup before theRay runtime-env venv's deps were importable; the retry that recovers this was made
re-entrancy-safe (roar's tracking import hook otherwise re-entered it into unbounded
recursion that pinned CPU and blocked worker registration).
fix(proxy): strip framing headers when re-serving buffered responses. S3 streamsCompleteMultipartUploadasTransfer-Encoding: chunkedwhitespace keep-alives; copyingthose framing headers onto the proxy's buffered body made hyper drop the connection
("empty reply from server", curl 52) on every multipart completion.
fix(ray): preserve async executors in the native-flush wrapper. The task-executorwrapper was sync-only, so wrapping an async actor method (Ray Train v2
TrainController.run) returned an un-awaited coroutine that failed to pickle ("cannotpickle 'coroutine' object").
fix(fragments): anchor the.roarfallback at the git root, not bare cwd. The Raysubmit planner runs in the workflow's
working_directorysubdir while the finalizer runsat the repo root; a cwd-relative fallback stranded the fragment session key and lineage
publication failed after a green run.
fix(publish): drop job labels for jobs not in the registration package. Labels arecollected from the raw lineage, but the package's jobs are noise-filtered by
normalize_jobs_for_registration; a label on a dropped noise job referenced a job absentfrom the package, so GLaaS rejected the publish with "Package label references a job not
in the package." Job-scoped labels are now filtered to the package's job set (dag/artifact
labels unaffected).
End-to-end validation
The distributed XGBoost/HIGGS tracer bullet was run from a laptop via
treqs runagainsta private EKS/KubeRay cluster and reached the full acceptance path:
COMPLETED— two distributed Ray Train workers on separate nodes, 80boost rounds, real validation metrics, artifacts written to versioned S3 through the roar
proxy.
publishedto GLaaS, session132ee59d…, a project-linked private publication undertreqs/treqs-modelswith aDAG label back to the training request. The published DAG contains 116 jobs (the
noise-filtered set).
Testing
pre-existing Docker-in-Docker harness limitation, not code).
tests/application/publish,label-command, label-sync, public-publish-intent).
ruff checkclean,mypyclean (405 source files),cargo test -p roar-proxy(24passed).
Notes
0.4.2 to PyPI is the recommended follow-up: it lets in-cluster Ray workers install
roar-cli==0.4.2from PyPI instead of a presigned wheel URL, removing the credentiallifetime coupling used during dogfood iteration.
mainand force-pushed; any prior per-commit reviewstate on this PR will have been reset.